Loops#

Spike detection with python#

  • Present data in code (individual voltage values, manipulate them and store the results) - variables

  • Compare variables (voltage to threshold) - boolean values

  • Perform different actions based on the value of a variable (only keep the position if the voltage exceeds the threshold) - if-else statements

  • Present and access data in a time series of voltage values - lists

  • Perform an action for each element in a sequence of values (inspect voltage values one-by-one) - for loops

  • Separate data and logic so we can use the same code for new recordings - functions

  • Apply this to multi data files

  • Plot and save the results

Warm-up#

To warm up, let’s do a little exercise.

Here is a list: my_list = [10, 20, 30, 40, 50]

Write code that divides each value in this list by 2 and prints the computed value.

my_list = [10, 20, 30, 40, 50]
# your solution here

Applying computations to list items using for-loops#

In the above code, the only thing that changes is the index.

We can re-write this much more succinctly using a for-loop:

my_list = [10, 20, 30, 40, 50]
indices = [0, 1, 2, 3, 4]  # create a list with the indices of the items you want to work with

for index in indices:  # "loop" over the list `indices`, one element at a time; at each step, 'index' holds the current number from 'indices'
    # the indented block is what is execute at each step of the loop
    item = my_list[index]  # use 'index' to access  the corresponding item from 'my_list'
    print(item)  # print the list item
10
20
30
40
50

What is going on here:

  • we define a variable called my_list as a list of 5 numbers: 10, 20, 30, 40, 50

  • we define another variable called indices as a list of 5 numbers: 0, 1, 2, 3, 4. We want to use this list as indices to access the items in my_list.

  • for index in indices does the following:

    • it creates a variable called index (the loop variable). the loop variable can have any name.

    • the for loop goes through the list, element by element and in each step (“for each element”) it does the following:

      • set the value of the indices variable to the current element (in the first step, the curent element is the first element in indices, in the second step, it is the second element in indices etc.)

      • execute the indented block (in this case, access the list element at index, and print the value of item)

    • repeat this until the end of the list is reached

Clicker question “indentation”(Click me!)

Q: How often will “Test” be printed when running below code? Never? Once? 4 times? 5 times?

my_list = [10, 20, 30, 40, 50]
indices = [0, 1, 2, 3, 4]

for index in indices:
    item = my_list[index]
    print(item)
print('Test')

Note how we isolate the code that is repeated, and replace what changes with a variable. This makes the code more general. That way, we can apply our computation, like comparing a voltage to a threshold, for every list element.

Mini-Exercise: To illustrate how the for loop works, we can print not only the item but also the index used to access the item in each step of the loop. Modify the code above to do that.

Generating indices using range objects#

Above, we generate the list of indices by hand. This is a bit tedious if we have a long lists with thousands of elements, like a voltage recording. The range function does that for you. In it’s simplest form, it takes an integer as an argument, and produces a range-object that can be used with a for loop to produces a series of integers:

indices = range(5)  # this is equivalent to `[0, 1, 2, 3, 4]
for index in indices:
    print("Item:", my_list[index])
Item: 10
Item: 20
Item: 30
Item: 40
Item: 50

Tip: It can be hard to learn about a range-object, since printing it does not return the indices it will generate. To do so, cast the range-object to a list and print it:

indices = range(5)  # this is equivalent to `[0, 1, 2, 3, 4]
print(list(indices))
[0, 1, 2, 3, 4]

Note that range works a bit like indexing: It starts at zero, and ends before the argument. range(5) produces integers starting at 0 and ending with 4. To generate a range-object with as many indices as the list we want to loop over, we can provide len(my_list) as the argument to range. This will produce indices ending with the index of the last element of our list:

length_of_list = len(my_list)
indices = range(length_of_list)
for index in indices:
    print("Item:", my_list[index])
Item: 10
Item: 20
Item: 30
Item: 40
Item: 50

Instead of simply printing the list elements, we can do any computation in the indented code block:

length_of_list = len(my_list)
indices = range(length_of_list)
for index in indices:
    result = my_list[index] / 2
    print("Result:", result)
Result: 5.0
Result: 10.0
Result: 15.0
Result: 20.0
Result: 25.0

Clicker question “range 1”(Click me!)

What list does list(range(3)) generate?

Clicker question “range 2”(Click me!)

Which range statement can be used to create the list [1, 3, 5]?

Exercises “For loops” 1-4.

Generating more flexible ranges#

So far, we have learned to use the range function to generate range-objects, by specifying the end: range(stop) will generate a range-object with indices starting at 0 and ending with stop-1.

However, there are two more ways to range:

  1. Specifying not only the end but also the start: range(start, stop)

  2. Specifying the start, the end, and a step size: range(start, stop, step)

You may have noticed that specifying a range works a bit like specifying slice indices.

range(5)  # stop=5: [0, 1, 2, 3, 4]
range(2, 5)  # start=2, stop=5: [2, 3, 4]
range(0, 5, 2);  # start=0, stop=5, step=2: [0, 2, 4]

Exercise “Loops” 5-7.

An alternative way of using for loops#

Above, we have used range objects to generate indices to access list elements in a for loop.

my_list = [10, 20, 30, 40]
indices = [0, 1, 2, 3]

for index in indices:
    item = my_list[index]
    print(index, item)
0 10
1 20
2 30
3 40

Note above, that we access the elements of indices, one after the other, in the for loop. We then use the indices, to access the elements of my_list. You may wonder: Can we access the elements of my_list directly in the for loop? Yes, we can!

my_list = [10, 20, 30, 40]

for item in my_list:
    print(item)
10
20
30
40

This is the standard way of accessing list elements using for loops in python. Range objects are typically used only if the index is needed for the computation. We will encounter these cases below. One example: When spike sorting, we want to compare each voltage value in our list of voltages to a threshold. In that case, we don’t care about the voltage value itself (=spike amplitude), but about the times (=indices) the threshold was crossed. In that case, you would need to use a range object. You can use either way of accessing list elements, using ranges or directly, whatever you find easier.

Different ways of looping over lists#

Directly over the elements#

So far, we have looped directly over list elements:

names = ['Tom', 'Yolanda', 'Estelle']
for name in names:
    print(name)
Tom
Yolanda
Estelle

Using indices#

names = ['Tom', 'Yolanda', 'Estelle']
for index in range(len(names)):  # loop over the indices
    name = names[index]  # get the current name using the index
    print(index, name)
0 Tom
1 Yolanda
2 Estelle

Clicker question “for loops 1”(Click me!)

What is the output of this code?

my_list = [1,4,12]
for number in my_list:
    print(number - 1)

Bonus: Nested loops#

For loops can be nested - indentation matters:

for outer in [10, 20, 30]:
    print('outer', outer)
    for inner in ['A', 'B', 'C']:
        print('   inner', outer, inner)
outer 10
   inner 10 A
   inner 10 B
   inner 10 C
outer 20
   inner 20 A
   inner 20 B
   inner 20 C
outer 30
   inner 30 A
   inner 30 B
   inner 30 C

Common code patterns#

Building lists#

So far we were able to manipulate list elements and print the result, but we did not have a way of saving the manipulated elements in a new list. You can do that by appending the results to a new empty list in the for loop:

# Goal: divide by ten each element in the list "data", collect the results in a new list "new_data"
data = [10, 20, 30]
print('data:', data)

# create an empty list that we will append the results to
new_data = []

# loop over all elements in data
for item in data:
    result = item / 10  # divide each list element by 10
    new_data.append(result)  # append the result to the `new_data` list

print('results:', new_data)
data: [10, 20, 30]
results: [1.0, 2.0, 3.0]

Important: append modifies the list in-place, it does not return a new list. This a = b.append(c) DOES NOT WORK!

Filtering lists#

Append the results to a new list using a for loop if they match a criterion:

numbers = [1, 15, 3, 2, 11, 7, 20]
print('numbers:', numbers)

# create an empty list that we will append the results to
results = []

# loop over all elements in numbers
for number in numbers:
    if number < 10:  # if the current number is <10
        results.append(number)  # add the number to our results list
print(results)
numbers: [1, 15, 3, 2, 11, 7, 20]
[1, 3, 2, 7]

Clicker question “filtering”(Click me!)

What is the content of big_numbers at the end of this code?

all_numbers = [1,12,4,19,28]
big_numbers = []

for number in my_list:
    if number > 10:
        big_numbers.append(number)
print(big_numbers)

While loops#

For loops are great if you have a pre-specified list of items and you want to do something with each item. Or if you have a computation and want to run it a fixed amount of times. However, sometimes you do not know beforehand how often you need to run a computation or you have an unspecified amount of items you need to work through.

So-called while loops allow you to apply a computation as long as a specified condition is met. While loops have the following form:

while CONDITION:
    do something

For instance, we can use a while loop to count down to zero:

count = 10

while count > 0:  # apply the indented code as long as the value of `count` is greater than 0
    print(count)
    count = count - 1

print(count)
10
9
8
7
6
5
4
3
2
1
0

Bonus content#

List comprehensions#

We very often encounter code that applies a function to list elements and appends the results ina new list using a for loop:

numbers = [123, 423, 540]

results = []
for number in numbers:
    results.append(number / 2)

List comprehensions are a way to write code like this more concisely:

results = [number / 2 for number in numbers]

Iterating over multiple lists in parallel using zip#

If we want to iterate over two lists in parallel, we can use indices produced by range to index into the different lists:

names = ['Tom', 'Ada', 'Jon']
ages = [14, 16, 12]

for index in range(len(names)):
    print(names[index], ages[index])
Tom 14
Ada 16
Jon 12

zip allows you to do that directly without having to use indices. Zip returns in each iteration a tuple of the individual list elemens:

names = ['Tom', 'Ada', 'Jon']
ages = [14, 16, 12]

for name, age in zip(names, ages):
    print(name, age)
Tom 14
Ada 16
Jon 12

Getting list indices and list elements in a for loop using enumerate#

If you need both the list indices and the elements, you can again use range:

names = ['Tom', 'Ada', 'Jon']

for index in range(len(names)):
    print(index, names[index])
0 Tom
1 Ada
2 Jon

enumerate returns can be more concise, since it returns a tuple of the index and the element in each iteration:

names = ['Tom', 'Ada', 'Jon']

for index, name in enumerate(names):
    print(index, name)
0 Tom
1 Ada
2 Jon

Spike detection using python#

  • Present data in code (individual voltage values, manipulate them and store the results) - variables

  • Compare variables (voltage to threshold) - boolean values

  • Perform different actions based on the value of a variable (only keep the position if the voltage exceeds the threshold) - if-else statements

  • Present and access data in a time series of voltage values - lists

  • Perform an action for each element in a sequence of values (inspect voltage values one-by-one) - for loops

Now you can write your spike detector!!

Next steps:

  • Separate data and logic so we can use the same code for new recordings - functions

  • Apply this to multi data files

  • Plot and save the results

  • Make everything more efficient and robust using numeric computation libraries (numpy, scipy)